使用Unity新一代输入系统实现可配置摄像机
我们已经介绍过Unity新一代的输入系统。本文,我们将使用Unity 2019.2开发可以移动、缩放和旋转的可配置摄像机。这种设计方法适用于不需要额外附带一个第一或第三人称摄像机,而是可以让游戏视角在场景自由移动的游戏。
摄像机的配置功能包括:
摄像机角度
最大和最小缩放设置
默认缩放设置
视线偏移(设定摄像机在Y轴上的观察位置)
旋转速度
学习目标
了解Unity新一代输入系统的重要概念。
获得可根据游戏进行自定义的可配置摄像机。
学习准备
你需要有使用Unity的基础知识。本文将不会介绍基础知识,包括:游戏对象和组件的概念、何时调用Start方法等。
本文中的项目使用了Low Poly: Free Pack资源:
https://assetstore.unity.com/packages/3d/environments/polyworks-free-pack-sample-58821
你可以复制本文的代码库,获得学习时用到的初始项目:
https://github.com/Unity-Technologies/InputSystem
安装新一代输入系统
Unity不断对输入系统进行全面的改进,以便使新一代输入系统更加强大而稳定,可以更好地适用于多种平台和设备配置。我们可以轻松配置该系统,使其能够处理多个本地玩家的输入。
请注意:新一代输入系统仍在不断完善开发中,处于预览阶段。
安装新一代输入系统,请通过资源包管理器安装Input System资源包,请按照以下步骤操作:
依次点击Window > Package Manager。
选择Advanced > Show Preview Packages,显示预览版资源包。
在搜索栏输入“Input System”,寻找该资源包。
选中Input System资源包,单击Install按钮。
通用渲染管线等Unity特定功能需要使用旧的输入系统。因此,我们最好确保项目设置中的Active Input Handling属性设为Both。这意味着我们可以在游戏中使用两种输入系统,但在本文中,我们只会使用新一代输入系统。
我们可以访问下面的设置,确定是否已经设置好该属性:依次点击Edit > Project Settings > Player > Configuration。
设置新一代输入系统
新一代输入系统比原有系统更为复杂。虽然初次学习难度更高,但会带来很好的回报。新系统更加强大稳定,在正确设置时,使用新系统所需的工作量会更少。
首先,我们要创建Input Controls输入控制资源,在项目窗口单击右键:
选择Create > Input Actions。
将新文件命名为PlayerInputMapping。
双击打开文件的编辑窗口。
配置输入时,有四个概念需要了解:
控制方案(Control Scheme):用于设置必须满足的设备要求,从而使输入绑定变得可用。这是可选设置,我们可以把它保留原样,即不设定要求。
动作导图(Action Maps):这是可以批量启用或禁用的动作组。
动作(Action):能够分组到特定动作下的一组输入绑定,例如:“开火”或“移动”等动作。
输入绑定(Input Bindings):用于指定要监视的设备输入,例如:手柄上的按键、鼠标按钮或键盘按键。
例如:在把动作设为多个输入绑定映射时,我们使用了“开火”动作,该动作会关联到手柄的特定按键,如果是键盘鼠标的设置方案,则会关联到鼠标右键。
动作导图、动作和输入绑定都有各自的属性。我们将在本文中详细介绍这些属性。
定义控制方式
输入方案将设计用于带有键盘和鼠标的设备,但如果需要,我们也可以轻松扩展到其它输入方式。总的而言,我们会有一个控制方案、一个动作导图、四个动作和五个输入绑定。
我们的设置如下图所示。
虽然上图看起来复杂,但创建该布局的方法其实很简单。打开PlayerInputMapping资源,创建一个新的动作导图:
单击Action Maps旁边的+图标,命名为Player。这会自动创建空白的Action部分和Input Binding节点。
把Action部分重命名为Camera_Move。设置以下属性:Action Type设为Value。Control Type设为Vector 2。
我们使用2D Vector Composite绑定节点,而不是使用默认创建的节点。每次按下W、S、A或D键时,该节点会告诉输入系统发送2D Vector数值。目前该部分不会起到任何作用,在把动作关联到摄像机后,我们会使用到该数值。
在空白的Binding部分单击右键,选择Delete,删除该节点。
右键单击Camera_Move动作,选择Add 2D Vector Composite,把新建的Binding部分命名为WASD。
选择名称有“Up: ”的部分,把Path设为W [Keyboard]。
对名称为Down、Left和Right的部分重复这些操作,把它们的Path设为对应的按键。
对方向键执行相同的操作。添加新的2D Vector Composite,命名为Arrows。设置每项映射到对应的方向键,现在我们会看到下图的设置。
我们现在需要设置剩余的动作和绑定:
添加新动作,命名为Camera_Rotate。
把Action Type设为Value,Control Type设为Vector 2。
单击绑定部分,把它的Path设为Delta (Mouse)。
接下来,我们要设置Camera_Rotate_Toggle的动作和绑定:
添加新动作,命名为Camera_Rotate_Toggle。
Action Type设为Button。
单击绑定部分,把Path设为Right Button [Mouse]。
最后,我们要设置Camera_Zoom动作和绑定:
添加新动作,命名为Camera_Zoom。
把Action Type设为Value,Control Type设为Vector 2。
单击绑定部分,把Path设为Scroll [Mouse]。
点击Save Asset保存改动。我们的导图画面如下图所示。
设置并移动摄像机
我们会使用两个游戏对象:CameraRig和Main Camera对象。
在场景中,创建空白游戏对象,命名为CameraRig。
把Main Camera对象设为CameraRig对象的子对象。
创建新脚本,命名为CameraController,把该脚本添加到CameraRig游戏对象。
CameraRig对象的作用是处理在场景中的移动和旋转。通过把这项功能作为单独的游戏对象来使用,我们可以随意在正向轴或右轴(Forward/Right axis)上移动,不必担心摄像机朝着哪个方向。
Main Camera对象会在开始时使用自定义属性来配置,确保它在世界空间中朝着正确方向。该对象也会处理缩放过程。
由于摄像机将是可配置的,因此我们首先定义可以在检视窗口设置的变量。
public class CameraController : MonoBehaviour
{
[Header("Configurable Properties")]
[Tooltip("This is the Y offset of our focal point. 0 Means we're looking at the ground.")] public float LookOffset;
[Tooltip("The angle that we want the camera to be at.")] public float CameraAngle;
[Tooltip("The default amount the player is zoomed into the game world.")] public float DefaultZoom;
[Tooltip("The most a player can zoom in to the game world.")] public float ZoomMax;
[Tooltip("The furthest point a player can zoom back from the game world.")] public float ZoomMin;
[Tooltip("How fast the camera rotates")] public float RotationSpeed;
}
我们将把摄像机角度设为45度,在地上1米的位置进行观察,并且把缩放大小限制在2-10米。检视窗口中可以设置的所有属性如下图所示。
接下来,基于设定的属性,配置摄像机的起始点。添加以下全局变量和Start()方法到脚本中。
//摄像机专用变量
private Camera _actualCamera;
private Vector3 _cameraPositionTarget;
void Start()
{
//存储对Camera Rig的引用
_actualCamera = GetComponentInChildren<Camera>();
//基于CameraAngle属性设置摄像机的旋转
_actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);
//基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。
_cameraPositionTarget = (Vector3.up * LookOffset) + (Quaternion.AngleAxis(CameraAngle, Vector3.right) * Vector3.back) * DefaultZoom;
_actualCamera.transform.position = _cameraPositionTarget;
}
最好存储Main Camera游戏对象的引用,而不是调用Camera.main。在Unity没有存储Main Camera对象的引用时,直接调用Camera.main会产生明显的性能影响,并在每次调用时遍历场景层级和组件。
添加移动行为
添加移动行为到摄像机时,我们需要多个全局变量,LateUpdate()中的调用和新的OnMove()方法。
//移动变量
private const float InternalMoveTargetSpeed = 8;
private const float InternalMoveSpeed = 4;
private Vector3 _moveTarget;
private Vector3 _moveDirection;
/// <summary>
/// 基于玩家提供的输入,设置移动方向。
/// </summary>
public void OnMove(InputAction.CallbackContext context)
{
//读取输入系统发送的输入数值。
Vector2 value = context.ReadValue<Vector2>();
//把数值存为Vector3类型,确保在Z轴上移动Y输入。
_moveDirection = new Vector3(value.x, 0, value.y);
//增加摄像机的新移动目标位置。
_moveTarget += (transform.forward * _moveDirection.z + transform.right * _moveDirection.x) * Time.fixedDeltaTime * InternalMoveTargetSpeed;
}
private void LateUpdate()
{
//把摄像机插补到新的移动目标位置。
transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);
}
OnMove()会通过调用context.ReadValue<Vector2>()来存储玩家输入数值。由于在使用Vector 2合成绑定,根据不同输入,我们会看到相应的X值和Y值:
Up: 0, 1
Down: 0, -1
Right: 1, 0
Left: -1, 0
将输入系统关联到代码
有了初始代码后,我们要进行测试运行,查看它的使用效果。为此,我们需要告诉输入系统什么时候发送动作。
我们添加Player Input组件到场景的游戏对象:
创建新游戏对象,命名为GameManager。
单击Add Component按钮,搜索Player Input组件。
设置以下属性:
Actions:设为刚刚配置的PlayerInputMapping资源。
Default Map:设为Player。
Behavior:设为Invoke Unity Events。
展开Events和Player部分。
在Camera_Move事件下,引用CameraRig对象,把事件设为CameraController.OnMove()。
我们现在可以进入运行模式,然后移动摄像机。
虽然我们使用的是Invoke Unity Events,即调用Unity事件通知行为,但也要了解不同选项及其作用:
Send Messages(发送信息):该选项会发送信息到该对象上的所有脚本。
Broadcast Messages(广播信息):除了把输入信息发送到同一对象上的组件外,该选项还会把信息发送到子对象层级。
Invoke Unity Events(调用Unity事件):该选项会为每种类型的信息调用UnityEvent。UI可用于设置回调方法。
Invoke C Sharp Events(调用C#事件):该选项类似Invoke Unity Events,但是会调用C#事件,这些事件必须通过脚本的回调来注册。
了解不同事件类型及其设置方式:
https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Components.html
修复摄像机移动
我们还未实现想要的行为,玩家应该能够按住按键,观察摄像机朝着相应方向持续移动的过程。
输入系统只会在按键按下时发送一次事件,输入系统没有简单的方法来监视按住按键的行为,因此我们需要自己解决该问题。
输入绑定有交互的概念,其中一项交互叫“Hold”。这项交互的作用是在按住按键的特定持续时间后触发动作,而在按住按钮时,它不会持续触发动作。
了解交互的更多内容:
https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Interactions.html#predefined-interactions
这个问题的解决方法很简单,我们只要把最后一行代码从OnMove()移动到FixedUpdate()中。
我们的代码如下所示:
public void OnMove(InputAction.CallbackContext context)
{
//读取输入系统发送的输入数值。
Vector2 value = context.ReadValue<Vector2>();
//把数值存为Vector3类型,确保在Z轴上移动Y输入。
_moveDirection = new Vector3(value.x, 0, value.y);
}
private void FixedUpdate()
{
//根据移动方向设置移动目标位置,该操作必须在此完成,因为输入系统没有逻辑来计算按住输入按键的事件。
_moveTarget += (transform.forward * _moveDirection.z + transform.right * _moveDirection.x) * Time.fixedDeltaTime * InternalMoveTargetSpeed;
}
进入运行模式后,我们的摄像机移动过程变得非常流畅,而且可以很好地处理方向变化,如下图所示。
添加缩放行为
添加缩放功能时,我们需要调整代码,使代码更简洁。这是因为摄像机需要能够根据当前缩放值,重新计算新的Y值和Z值。
首先,我们添加下列全局变量和UpdateCameraTarget()方法。
//缩放变量
private float _currentZoomAmount;
public float CurrentZoom
{
get => _currentZoomAmount;
private set
{
_currentZoomAmount = value;
UpdateCameraTarget();
}
}
private float _internalZoomSpeed = 4;
/// <summary>
/// 根据多个属性计算新的位置
/// </summary>
private void UpdateCameraTarget()
{
_cameraPositionTarget = (Vector3.up * LookOffset) + (Quaternion.AngleAxis(CameraAngle, Vector3.right) * Vector3.back) * _currentZoomAmount;
}
我们可以更新Start()方法,把CurrentZoom设为DefaultZoom的数值,而不是让脚本计算数值,代码如下所示。
void Start()
{
//存储对Camera Rig的引用
_actualCamera = GetComponentInChildren<Camera>();
//基于CameraAngle属性设置摄像机的旋转。
_actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);
//基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。
CurrentZoom = DefaultZoom;
_actualCamera.transform.position = _cameraPositionTarget;
}
接下来,添加新的OnZoom()方法,更新LateUpdate()方法,使其基于新的缩放系数来移动_actualCamera的本地位置。
/// <summary>
/// 设置缩小和放大的逻辑。限制为最小值和最大值。
/// </summary>
/// <param name="context"></param>
public void OnZoom(InputAction.CallbackContext context)
{
if (context.phase != InputActionPhase.Performed)
{
return;
}
// 根据滚动方向调整当前缩放值,该值的大小限制为最大值和最小值之间。
CurrentZoom = Mathf.Clamp(_currentZoomAmount - context.ReadValue<Vector2>().y, ZoomMax, ZoomMin);
}
private void LateUpdate()
{
//把摄像机插补到新的移动目标位置。
transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);
//根据新的缩放系数,移动_actualCamera的本地位置。
_actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);
}
根据输入阶段的不同,事件的多个实例会以不同的状态发送。对于OnZoom(),如果处于Performed状态,我们只想处理读取数值的部分,因为这会确保我们不会得到扰乱逻辑的数值。如果没有这项检查,我们会在Started状态和Canceled状态处理两个以上的调用。
了解输入动作状态的更多内容:
https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/api/UnityEngine.InputSystem.InputActionPhase.html
现在我们要进行测试。通过关联Move事件的方法,把逻辑关联到输入系统:
在Camera_Zoom事件下,引用CameraController游戏对象,把事件设为CameraController.OnZoom。
运行项目,然后滚动鼠标滚轮。
我们发现,缩放值会在设置的最大缩放值和最小缩放值之间切换,而不是逐渐递增。这是因为滚动鼠标滚轮时,发送的输入值太大,每次滚动发出的Vector 2值都会是(0, 120)或(0, -120)。
为了实现缓慢地逐渐递增,我们的逻辑需要把数值归一化为(0, 1)或(0, -1)。为此,我们进行以下操作:
打开PlayerInputMapping资源,选中Camera_Zoom动作下的Scroll [Mouse]绑定。
在属性面板,单击Processors部分下的+按钮,选择Normalize Vector 2。
保存文件。
我们有许多实用的处理器可以应用到动作、控制和绑定,包括:为手柄输入指定空白区域数值。
了解更多不同事件类型及设置方法的内容:
https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/Processors.html
如下图所示,现在我们会看到流畅的滚动行为。
添加旋转行为
摄像机的旋转过程由两步组成。首先,我们需要知道玩家是否在让摄像机旋转。这一步会监视玩家是否按下鼠标右键。如果按下了鼠标右键,我们会获取鼠标位置,告诉游戏应该朝什么方向旋转。
监视按钮操作非常简单,我们只需要读取某个浮点值是0(关闭)还是1(启用)即可。为此,我们要给脚本添加以下全局变量和OnRotateToggle()方法。
//旋转变量
private bool _rightMouseDown = false;
private const float InternalRotationSpeed = 4;
private Quaternion _rotationTarget;
private Vector2 _mouseDelta;
/// <summary>
/// 设置玩家是否按下鼠标右键。
/// </summary>
/// <param name="context"></param>
public void OnRotateToggle(InputAction.CallbackContext context)
{
_rightMouseDown = context.ReadValue<float>() == 1;
}
给脚本添加OnRotate()方法,该方法会在按下鼠标右键时,旋转摄像机。
/// <summary>
/// 如果玩家按下鼠标右键并移动鼠标,则设置旋转目标的Quaternion类数值。
/// </summary>
/// <param name="context"></param>
public void OnRotate(InputAction.CallbackContext context)
{
// 如果按下鼠标右键,我们会读取鼠标的_mouseDelta值。如果没有按下,我们会清零该值。
// 请注意:清零_mouseDelta值会避免在玩家朝某个方向快速移动鼠标时,发生“Death Spin”情况。
_mouseDelta = _rightMouseDown ? context.ReadValue<Vector2>() : Vector2.zero;
_rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up);
}
最后,给LateUpdate()方法和Start()方法添加逻辑,让它们旋转摄像机。
void Start()
{
//存储对Camera Rig的引用
_actualCamera = GetComponentInChildren<Camera>();
//基于CameraAngle属性设置摄像机的旋转。
_actualCamera.transform.rotation = Quaternion.AngleAxis(CameraAngle, Vector3.right);
//基于Look Offset、Camera Angle和Default Zoom属性,设置摄像机的位置。这会确保我们观察正确的焦点。
CurrentZoom = DefaultZoom;
_actualCamera.transform.position = _cameraPositionTarget;
//设置初始旋转值。
_rotationTarget = transform.rotation;
}
private void LateUpdate()
{
//把Camera Rig插值到新的移动目标位置
transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);
//根据新的缩放系数,移动_actualCamera的本地位置。
_actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);
//根据新的目标,对Camera Rig的旋转进行球面插值。
transform.rotation = Quaternion.Slerp(transform.rotation, _rotationTarget, Time.deltaTime * InternalRotationSpeed);
}
根据新的方法,把逻辑关联到输入系统:
在Camera_Rotate事件下,引用CameraController游戏对象,把事件设为CameraController.OnRotate。
在Camera_Rotate_Toggle事件下,引用CameraController游戏对象,把事件设为CameraController.OnRotateToggle。
运行项目,按住鼠标右键并移动鼠标。
虽然此时看似正常运行,但我们为了更新旋转状态使用了过多的不必要调用。为了更好了解情况,我们要知道OnRotate()输入事件每帧会进行多少次调用。
我们会加入一些临时代码来展示次数。
// 创建新的全局变量。
private float _eventCounter;
// 添加以下代码到OnRotate方法的结尾。
// 该代码会在每次事件调用时,递增eventCounter值。
eventCounter += _rightMouseDown ? 1 : 0;
// 添加下面代码到LateUpdate方法的结尾。
// 由于LateUpdate方法会在每帧运行一次,因此它会记录事件在每帧调用的总次数,然后在下次检查时清空结果。
Debug.Log(eventCounter);
eventCounter = 0;
在运行代码并旋转摄像机时,我们可以看到每一帧都多次触发OnRotate()事件。
此外在一帧中,随着每次事件触发而发送的鼠标增量会逐渐增长。考虑到这一点,我们最好每帧应用一次最终增量值。
为此,把_rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up); 代码从OnRotate()方法移动到LateUpdate()方法中。
private void LateUpdate()
{
//把Camera Rig插值到新的移动目标位置
transform.position = Vector3.Lerp(transform.position, _moveTarget, Time.deltaTime * InternalMoveSpeed);
//根据新的缩放系数,移动_actualCamera的本地位置。
_actualCamera.transform.localPosition = Vector3.Lerp(_actualCamera.transform.localPosition, _cameraPositionTarget, Time.deltaTime * _internalZoomSpeed);
//根据鼠标增量位置和旋转速度,设置目标旋转。
_rotationTarget *= Quaternion.AngleAxis(_mouseDelta.x * Time.deltaTime * RotationSpeed, Vector3.up);
//根据新的目标,对Camera Rig的旋转进行球面插值。
transform.rotation = Quaternion.Slerp(transform.rotation, _rotationTarget, Time.deltaTime * InternalRotationSpeed);
}
大功告成,我们现在拥有了功能完善的摄像机,它能够在场景中通过使用新一代输入系统进行旋转、缩放和移动。我们可以在检视窗口通过调整RotationSpeed变量来增加速度。
小结
通过本文的学习,我们希望开发者能够熟练掌握好Unity新一代的输入系统。如果你有任何反馈,请访问Unity官方论坛:
https://forum.unity.com/forums/new-input-system.103
下载Unity Connect APP,请点击此处。 观看更多Unity官方精彩视频,请关注“Unity官方”B站账户。
你可以访问Unity答疑专区留下你的问题,Unity社区和官方团队帮你解答:
Connect.unity.com/g/discussion
推荐阅读
2020年Unity Pro专业版和Plus加强版订阅价格将调整
Unity Labs新一代AR/MR工具:Project MARS
直播课程
10月30日的直播课程由Unity技术经理成亮为你进行Unity 2019.3最新2D功能实例讲解。[了解详情.....]
直播时间:10月30日 20:00-21:00 (明晚!!! )
直播地址:
https://connect.unity.com/i/a8a694a6-4ffc-4f70-9db2-58ca02f98826
觉得实用,点个“在看”吧